大家好,我是 Alex。
前幾天已經完成 Kotlin 基本語法、集合、物件導向與協程等內容。從今天開始,我們要正式進入 Android App 的畫面開發。
今天將從 Android Studio 的版面配置編輯器開始,學習如何使用 ConstraintLayout 安排元件位置,再透過 View Binding 操作畫面上的 TextView 與 Button,最後使用 Fragment 完成多畫面切換及返回功能。
參考資料:Android Developers-開發 UI|Android Developers-ConstraintLayout
傳統 Android View 系統通常把畫面與程式邏輯分開:
例如,在 XML 中加入一個 Button 後,可以在 Kotlin 裡設定點擊監聽器;當使用者按下按鈕時,再修改 TextView 的文字或切換至另一個 Fragment。
參考資料:Android Developers-Layouts|Android Developers-Activity
Android Studio 提供視覺化版面配置編輯器,可以把 TextView、Button、ImageView 等元件直接拖曳到畫面中。
版面編輯器主要分成三種檢視模式:
Design:顯示畫面實際呈現的樣子。Blueprint:顯示元件之間的約束關係。Code:直接編輯 XML 原始碼。在 ConstraintLayout 中,元件不應只靠拖曳決定位置,而是必須建立 Constraint 約束。沒有完整約束的元件,在不同尺寸的手機上可能會出現位移。
參考資料:Android Developers-Layout Editor|Android Developers-ConstraintLayout
ConstraintLayout 是 Android 常用的版面配置之一。每一個元件都可以和父容器或其他元件建立上下左右的約束。
常用的約束屬性如下:
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
如果要讓一個 TextView 水平置中,可以把它的左右兩側都約束到父容器:
<TextView
android:id="@+id/textView_text1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hi good morning"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="72dp" />
這樣不論手機螢幕寬度如何改變,TextView 都會維持在水平方向的中央。
參考資料:Android Developers-ConstraintLayout 約束
Guideline 是 ConstraintLayout 中看不見的輔助線。它不會顯示在 App 畫面上,只負責提供其他元件建立約束。
例如,建立一條距離畫面頂端 40dp 的水平 Guideline:
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="40dp" />
接著,其他元件就可以約束在 Guideline 下方:
app:layout_constraintTop_toBottomOf="@id/guideline"
除了固定距離,Guideline 也能使用百分比定位:
app:layout_constraintGuide_percent="0.5"
0.5 代表位於父容器的 50% 位置,也就是畫面的正中央。百分比定位通常比寫死固定寬度更能適應不同尺寸的裝置。
參考資料:Android Developers-Guideline
早期常見的 Android View 寫法,是先呼叫 setContentView() 載入 XML,再使用 findViewById() 取得元件。
以下是本次練習中的完整程式:
package com.example.layout2
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
class MainActivity : AppCompatActivity() {
// 記錄兩個按鈕目前的切換狀態
private var buttonFlag1: Boolean = true
private var buttonFlag2: Boolean = true
// 使用 lateinit 延後初始化畫面元件
private lateinit var buttonText2: Button
private lateinit var buttonText1: Button
private lateinit var textView3_Content: TextView
private lateinit var textView2_Center: TextView
private lateinit var textView1_Title: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 啟用 Edge-to-Edge,讓內容可以延伸到系統列區域
enableEdgeToEdge()
// 載入 activity_main.xml
setContentView(R.layout.activity_main)
// 讀取系統狀態列與導覽列的範圍,避免內容被遮住
ViewCompat.setOnApplyWindowInsetsListener(
findViewById(R.id.main)
) { view, insets ->
val systemBars =
insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(
systemBars.left,
systemBars.top,
systemBars.right,
systemBars.bottom
)
insets
}
// 使用 ID 取得 XML 中的 TextView
textView1_Title = findViewById(R.id.textView_text1)
textView2_Center = findViewById(R.id.textView_text2)
textView3_Content = findViewById(R.id.textView_text3)
// 取得兩個 TextView 原本顯示的文字
val data1 = textView1_Title.text
val data2 = textView2_Center.text
// 使用字串模板組合內容
textView3_Content.text = "$data1\n$data2\n\n"
// 使用 append() 繼續加入文字
textView3_Content.append(
data1.toString() + "\n" + data2.toString()
)
// 設定按鈕的初始切換狀態
buttonFlag1 = true
buttonFlag2 = true
// 取得第一個按鈕
buttonText1 = findViewById(R.id.button)
// 第一個按鈕使用匿名物件設定監聽器
buttonText1.setOnClickListener(
object : View.OnClickListener {
override fun onClick(view: View?) {
if (buttonFlag1) {
// 第一次點擊時修改第一個 TextView
textView1_Title.text = "Hi good afternoon"
buttonFlag1 = false
} else {
// 再次點擊時恢復原本內容
textView1_Title.text = data1
buttonFlag1 = true
}
}
}
)
// 取得第二個按鈕
buttonText2 = findViewById(R.id.button2)
// 第二個按鈕同樣使用匿名物件設定監聽器
buttonText2.setOnClickListener(
object : View.OnClickListener {
override fun onClick(view: View?) {
if (buttonFlag2) {
// 第一次點擊時修改第二個 TextView
textView2_Center.text = "It is a sunny day"
buttonFlag2 = false
} else {
// 再次點擊時恢復原本內容
textView2_Center.text = data2
buttonFlag2 = true
}
}
}
)
}
}
這個範例使用兩個 Boolean 變數保存按鈕狀態。每按一次按鈕就反轉狀態,讓文字能在新內容與原始內容之間切換。
參考資料:Android Developers-findViewById|Android Developers-處理點擊事件
findViewById() 雖然容易理解,但也有幾個缺點:
findViewById()。因此,Android 官方提供了 View Binding,它會根據 XML 版面配置自動產生 Binding 類別。
參考資料:Android Developers-View Binding
首先開啟 App 模組的 build.gradle.kts,在 android 區塊加入:
android {
// 其他 Android 專案設定
buildFeatures {
// 啟用 View Binding
viewBinding = true
}
}
如果使用較早版本的 Android Gradle Plugin,也可能會看到以下寫法:
android {
// 舊版語法
viewBinding {
enable = true
}
}
修改 Gradle 設定後,需要按下 Android Studio 上方的 Sync Now,讓專案重新同步。
參考資料:Android Developers-啟用 View Binding
啟用 View Binding 後,Android Studio 會依照 XML 檔名產生 Binding 類別。
| XML 檔名 | 自動產生的 Binding 類別 |
|---|---|
activity_main.xml |
ActivityMainBinding |
fragment_first.xml |
FragmentFirstBinding |
fragment_second.xml |
FragmentSecondBinding |
XML 中的 ID 也會自動轉成駝峰式命名:
| XML ID | Kotlin Binding 屬性 |
|---|---|
button_1 |
binding.button1 |
text_view_text1 |
binding.textViewText1 |
fragment_container |
binding.fragmentContainer |
因此,不需要再使用 findViewById(),就可以直接取得畫面元件。
參考資料:Android Developers-View Binding 使用方式
以下是這次練習中的 View Binding 完整程式:
package com.example.layout3
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.layout3.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
// 宣告 Activity 對應的 View Binding
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 啟用 Edge-to-Edge 顯示
enableEdgeToEdge()
// 根據 activity_main.xml 建立 Binding 物件
binding = ActivityMainBinding.inflate(layoutInflater)
// binding.root 代表 XML 最外層的根 View
setContentView(binding.root)
// 使用 Binding 直接取得 ID 為 main 的根版面
ViewCompat.setOnApplyWindowInsetsListener(
binding.main
) { view, insets ->
val systemBars =
insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(
systemBars.left,
systemBars.top,
systemBars.right,
systemBars.bottom
)
insets
}
// 第一個按鈕被點擊後,修改第一個 TextView
binding.button1.setOnClickListener {
binding.textViewText1.text = "Button-1 Clicked"
}
// 第二個按鈕被點擊後,修改第二個 TextView
binding.button2.setOnClickListener {
binding.textViewText2.text = "Button-2 Clicked"
}
// 第三個按鈕被點擊後,修改第三個 TextView
binding.button3.setOnClickListener {
binding.textViewText3.text = "Button-3 Clicked"
}
}
}
與 findViewById() 相比,View Binding 的寫法更簡潔。只要輸入 binding.,Android Studio 就會列出目前 XML 中可使用的元件,也能在編譯時檢查元件型別。
參考資料:Android Developers-在 Activity 使用 View Binding
Activity 可以想像成一個容器,Fragment 則是放在容器裡面的子畫面。
例如,主畫面中有兩個按鈕:
FirstFragment。SecondFragment。這種方式不必為每一個小畫面都建立新的 Activity,也方便在同一個 Activity 中替換不同內容。
參考資料:Android Developers-Fragments|Android Developers-FragmentManager
Activity 的 XML 需要準備一個容器,讓 Fragment 顯示在指定區域。
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/button2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
這裡將 layout_width 與 layout_height 設為 0dp,代表元件大小由 Constraint 約束決定。在 ConstraintLayout 中,這種設定稱為 match constraints。
參考資料:Android Developers-FragmentContainerView|Android Developers-ConstraintLayout 尺寸
以下是加入 Fragment 切換後的完整 MainActivity.kt:
package com.example.layout3
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import com.example.layout3.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
// Activity 對應的 View Binding
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 啟用 Edge-to-Edge 顯示
enableEdgeToEdge()
// 建立 View Binding
binding = ActivityMainBinding.inflate(layoutInflater)
// 將 Binding 的根 View 設為 Activity 畫面
setContentView(binding.root)
// 處理狀態列與導覽列範圍
ViewCompat.setOnApplyWindowInsetsListener(
binding.main
) { view, insets ->
val systemBars =
insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(
systemBars.left,
systemBars.top,
systemBars.right,
systemBars.bottom
)
insets
}
// 按下第一個按鈕,開啟 FirstFragment
binding.button1.setOnClickListener {
replaceFragment(
fragment = FirstFragment(),
tag = "first"
)
}
// 按下第二個按鈕,開啟 SecondFragment
binding.button2.setOnClickListener {
replaceFragment(
fragment = SecondFragment(),
tag = "second"
)
}
}
/**
* 將指定的 Fragment 放進 fragmentContainer。
*
* @param fragment 準備顯示的 Fragment
* @param tag 放入返回堆疊時使用的識別名稱
*/
private fun replaceFragment(
fragment: Fragment,
tag: String
) {
supportFragmentManager
.beginTransaction()
// 使用新的 Fragment 取代容器中的舊內容
.replace(
R.id.fragmentContainer,
fragment
)
// 加入返回堆疊,讓使用者可以回到上一個畫面
.addToBackStack(tag)
// 提交這次 FragmentTransaction
.commit()
}
/**
* 處理 Toolbar 向上返回按鈕。
*/
override fun onSupportNavigateUp(): Boolean {
return if (
supportFragmentManager.backStackEntryCount > 0
) {
// 返回堆疊中還有畫面時,移除最上層 Fragment
supportFragmentManager.popBackStack()
true
} else {
// 沒有 Fragment 可返回時,交由 Activity 原本的行為處理
super.onSupportNavigateUp()
}
}
}
replace() 負責把新的 Fragment 放進容器,而 addToBackStack() 則會記錄這次切換。若沒有呼叫 addToBackStack(),使用者按下返回鍵時就不能回到前一個 Fragment 狀態。
參考資料:Android Developers-FragmentTransaction|Android Developers-Fragment 返回堆疊
FirstFragment 透過 onCreateView() 載入 fragment_first.xml。按下 Fragment 裡的返回按鈕後,呼叫 popBackStack() 回到上一層。
完整程式如下:
package com.example.layout3
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.fragment.app.Fragment
class FirstFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
// 載入 fragment_first.xml
// container 是這個 Fragment 將被放入的父容器
val view = inflater.inflate(
R.layout.fragment_first,
container,
false
)
// 取得 Fragment 畫面裡的返回按鈕
val backButton =
view.findViewById<Button>(R.id.buttonBack)
// 按下按鈕後,移除返回堆疊最上層的 Fragment
backButton.setOnClickListener {
parentFragmentManager.popBackStack()
}
// 回傳建立完成的 Fragment View
return view
}
}
parentFragmentManager 是管理此 Fragment 的 FragmentManager。呼叫 popBackStack() 後,會移除目前位於返回堆疊最上層的 Fragment 交易。
參考資料:Android Developers-建立 Fragment|Android Developers-FragmentManager.popBackStack
第二個 Fragment 的結構與第一個相同,只是載入不同的 XML:
package com.example.layout3
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.fragment.app.Fragment
class SecondFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
// 載入 fragment_second.xml
val view = inflater.inflate(
R.layout.fragment_second,
container,
false
)
// 取得第二個 Fragment 畫面裡的返回按鈕
val backButton =
view.findViewById<Button>(R.id.buttonBack)
// 返回前一個畫面
backButton.setOnClickListener {
parentFragmentManager.popBackStack()
}
// 回傳 Fragment 的根 View
return view
}
}
這兩個 Fragment 都使用相同 ID 的 buttonBack。由於它們分別屬於不同的 XML 版面配置,因此不會互相衝突。
參考資料:Android Developers-Fragment|Android Developers-Fragment 返回堆疊
這個範例執行後的流程如下:
MainActivity 使用 View Binding 載入 activity_main.xml。button1。FirstFragment。replaceFragment() 把 Fragment 放入 fragmentContainer。addToBackStack("first") 記錄這次畫面交易。popBackStack() 移除目前 Fragment。第二個按鈕與 SecondFragment 也會執行相同流程。
參考資料:Android Developers-Fragment 交易
| 比較項目 | findViewById | View Binding |
|---|---|---|
| 取得元件方式 | 手動輸入 ID | Binding 自動產生屬性 |
| 型別安全 | 較低 | 較高 |
| 空值安全 | 需要自行處理 | 對現有 View 提供直接參照 |
| 程式碼長度 | 較多 | 較精簡 |
| 編譯時檢查 | 有限 | 可檢查多數 ID 與型別問題 |
| 適合情境 | 舊專案、基本教學 | 新專案與一般 Android 開發 |
對剛開始學 Android 的人來說,先理解 findViewById() 有助於認識 XML 與 Kotlin 的連結方式;實際建立新專案時,則建議使用 View Binding。
參考資料:Android Developers-View Binding
今天正式跨入 Android App 的畫面開發,完成的內容包括:
ConstraintLayout 建立元件約束。Guideline 輔助元件定位。findViewById() 取得 XML 元件。TextView 文字。ActivityMainBinding 操作畫面元件。FragmentContainerView 準備 Fragment 容器。FragmentTransaction.replace() 切換畫面。addToBackStack() 保存畫面切換紀錄。popBackStack() 返回上一個畫面。從這一天開始,我們已經不只是撰寫顯示在終端機裡的 Kotlin 程式,而是真正讓程式與 Android 手機畫面互動。下一步可以繼續整理 Activity 與 Fragment 的生命週期,以及 Fragment 中更安全的 View Binding 寫法。
參考資料:Android Developers-View 系統|Android Developers-Fragment 生命週期